// Copyright 2023 syzkaller project authors. All rights reserved.
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.

package main

import (
	"fmt"
	"reflect"
	"regexp"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/google/syzkaller/dashboard/dashapi"
	"github.com/stretchr/testify/assert"
	db "google.golang.org/appengine/v2/datastore"
	aemail "google.golang.org/appengine/v2/mail"
)

func TestTreeOriginDownstream(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `downstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results:    []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.reportToEmail()
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels(`origin:downstream`)
	// It should habe been enough to run jobs just once.
	c.expectEQ(ctx.entries[0].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
	c.expectEQ(ctx.entries[2].jobsDone, 1)
	// Test that we can render the bug page.
	_, err := c.GET(ctx.bugLink())
	c.expectEQ(err, nil)
	// Test that we receive a notification.
	msg := ctx.emailWithoutURLs()
	c.expectEQ(msg.Body, `Bug presence analysis results: the bug reproduces only on the downstream tree.

syzbot has run the reproducer on other relevant kernel trees and got
the following results:

downstream (commit ffffffffffff) on 2000/01/11:
crash title
Report: %URL%

lts (commit ffffffffffff) on 2000/01/11:
Didn't crash.

upstream (commit ffffffffffff) on 2000/01/11:
Didn't crash.

More details can be found at:
%URL%
`)
	// Test that these results are also in the full bug info.
	info := ctx.fullBugInfo()
	c.expectEQ(len(info.TreeJobs), 3)
	c.expectEQ(info.TreeJobs[0].KernelAlias, `downstream`)
	c.expectNE(info.TreeJobs[0].CrashTitle, ``)
	c.expectEQ(info.TreeJobs[1].KernelAlias, `lts`)
	c.expectEQ(info.TreeJobs[1].CrashTitle, ``)
	c.expectEQ(info.TreeJobs[2].KernelAlias, `upstream`)
	c.expectEQ(info.TreeJobs[2].CrashTitle, ``)
}

func TestTreeOriginDownstreamEmail(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `downstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results:    []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)

	// The report must contain the string.
	msg := ctx.reportToEmail()
	assert.Contains(t, msg.Body, `@testapp.appspotmail.com

Bug presence analysis results: the bug reproduces only on the downstream tree.


report1

---
This report is generated by a bot. It may contain errors.`)
	// No notification must be sent.
	c.client.pollNotifs(0)
}

func TestTreeOriginBetterReport(t *testing.T) {
	// Ensure that, once a higher priority crash becomes available, we perform origin testing again.
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `downstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results:    []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10, 20, 30}
	ctx.moveToDay(10)
	ctx.ensureLabels("origin:downstream")
	c.expectEQ(ctx.entries[1].jobsDone, 1)
	c.expectEQ(ctx.entries[2].jobsDone, 1)

	// No retets are needed yet.
	ctx.moveToDay(20)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
	c.expectEQ(ctx.entries[2].jobsDone, 1)

	// Use a "better" manager.
	ctx.manager = "better-manager"
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)

	// With that reproducer, lts begins to crash as well.
	ctx.entries[1].results = []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}}
	ctx.moveToDay(30)
	ctx.ensureLabels("origin:lts")
	c.expectEQ(ctx.entries[1].jobsDone, 2)
	c.expectEQ(ctx.entries[2].jobsDone, 2)
}

func TestTreeOriginLts(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `downstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results:    []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels(`origin:lts`)
	c.expectEQ(ctx.entries[0].jobsDone, 0)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
	c.expectEQ(ctx.entries[2].jobsDone, 1)
	// Test that we don't receive any notification.
	ctx.reportToEmail()
	ctx.ctx.expectNoEmail()
}

// This function is very very big, but the required scenario is unfortunately
// also very big, so:
// nolint: funlen
func TestTreeOriginLtsBisection(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `downstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results: []treeTestEntryPeriod{
				{
					fromDay: 0,
					result:  treeTestCrash,
					commit:  "badc0ffee",
				},
			},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels(`origin:lts`)
	ctx.reportToEmail()
	ctx.ctx.advanceTime(time.Hour)

	// Expect a cross tree bisection request.
	job := ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{BisectFix: true})
	assert.Equal(t, dashapi.JobBisectFix, job.Type)
	assert.Equal(t, "https://upstream.repo/repo", job.KernelRepo)
	assert.Equal(t, "upstream-master", job.KernelBranch)
	assert.Equal(t, "https://lts.repo/repo", job.MergeBaseRepo)
	assert.Equal(t, "lts-master", job.MergeBaseBranch)
	assert.Equal(t, "badc0ffee", job.KernelCommit)
	ctx.ctx.advanceTime(time.Hour)

	// Make sure we don't create the same job twice.
	job2 := ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{BisectFix: true})
	assert.Equal(t, "", job2.ID)
	ctx.ctx.advanceTime(time.Hour)

	// Let the bisection fail.
	done := &dashapi.JobDoneReq{
		ID:    job.ID,
		Log:   []byte("bisect log"),
		Error: []byte("bisect error"),
	}
	c.expectOK(ctx.client.JobDone(done))
	ctx.ctx.advanceTime(time.Hour)

	// Ensure there are no new bisection requests.
	job = ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{BisectFix: true})
	assert.Equal(t, job.ID, "")

	// Wait for the cooldown and request the job once more.
	ctx.ctx.advanceTime(15 * 24 * time.Hour)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.ctx.advanceTime(15 * 24 * time.Hour)
	job = ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{BisectFix: true})
	assert.Equal(t, job.KernelRepo, "https://upstream.repo/repo")
	assert.Equal(t, job.KernelCommit, "badc0ffee")

	// This time pretend we have found the commit.
	build := testBuild(2)
	build.KernelRepo = job.KernelRepo
	build.KernelBranch = job.KernelBranch
	build.KernelCommit = "deadf00d"
	done = &dashapi.JobDoneReq{
		ID:          job.ID,
		Build:       *build,
		Log:         []byte("bisect log 2"),
		CrashTitle:  "bisect crash title",
		CrashLog:    []byte("bisect crash log"),
		CrashReport: []byte("bisect crash report"),
		Commits: []dashapi.Commit{
			{
				AuthorName: "Someone",
				Author:     "someone@somewhere.com",
				Hash:       "deadf00d",
				Title:      "kernel: fix a bug",
				Date:       time.Date(2000, 2, 9, 4, 5, 6, 7, time.UTC),
			},
		},
	}
	done.Build.ID = job.ID
	ctx.ctx.advanceTime(time.Hour)
	c.expectOK(ctx.client.JobDone(done))

	// Ensure the job is no longer created.
	ctx.ctx.advanceTime(time.Hour)
	job = ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{BisectFix: true})
	assert.Equal(t, job.ID, "")

	msg := ctx.emailWithoutURLs()
	c.expectEQ(msg.Body, `syzbot suspects this issue could be fixed by backporting the following commit:

commit deadf00d
git tree: upstream
Author: Someone <someone@somewhere.com>
Date:   Wed Feb 9 04:05:06 2000 +0000

    kernel: fix a bug

bisection log:  %URL%
final oops:     %URL%
console output: %URL%
kernel config:  %URL%
dashboard link: %URL%
syz repro:      %URL%
C reproducer:   %URL%


Please keep in mind that other backports might be required as well.

For information about bisection process see: %URL%#bisection
`)
	ctx.ctx.expectNoEmail()

	info := ctx.fullBugInfo()
	assert.NotNil(t, info.FixCandidate)
	fix := info.FixCandidate
	assert.Equal(t, "upstream", fix.KernelRepoAlias)
	assert.NotNil(t, fix.BisectFix)
	assert.NotNil(t, fix.BisectFix.Commit)
	commit := fix.BisectFix.Commit
	assert.Equal(t, "deadf00d", commit.Hash)
	assert.Equal(t, "kernel: fix a bug", commit.Title)

	// Ensure the bug is not automatically closed.
	bug := ctx.loadBug()
	assert.Len(t, bug.Commits, 0)

	// Ensure the bug is present on the backports list page.
	reply, err := ctx.ctx.AuthGET(AccessAdmin, "/tree-tests/backports")
	c.expectOK(err)
	assert.Contains(t, string(reply), treeTestCrashTitle)
	assert.Contains(t, string(reply), "deadf00d")

	// But don't show this to all users.
	reply, err = ctx.ctx.AuthGET(AccessPublic, "/tree-tests/backports")
	c.expectOK(err)
	assert.NotContains(t, string(reply), treeTestCrashTitle)

	// Check that we display it in another related namespace.
	upstreamBuild := testBuild(100)
	upstreamBuild.KernelRepo = "https://upstream.repo/repo"
	upstreamBuild.KernelBranch = "upstream-master"
	ctx.ctx.publicClient.UploadBuild(upstreamBuild)
	reply, err = ctx.ctx.AuthGET(AccessAdmin, "/access-public-email/backports")
	c.expectOK(err)
	assert.Contains(t, string(reply), treeTestCrashTitle)

	// .. but, again, not to everyone.
	reply, err = ctx.ctx.AuthGET(AccessPublic, "/access-public-email/backports")
	c.expectOK(err)
	assert.NotContains(t, string(reply), treeTestCrashTitle)

	// The bug must appear in commit poll.
	commitPollResp, err := ctx.client.CommitPoll()
	c.expectOK(err)
	assert.Contains(t, commitPollResp.Commits, "kernel: fix a bug")

	// Pretend that we have found a commit.
	c.expectOK(ctx.client.UploadCommits([]dashapi.Commit{
		{
			Hash:       "newhash",
			Title:      "kernel: fix a bug",
			AuthorName: "Someone",
			Author:     "someone@somewhere.com",
			Date:       time.Date(2000, 3, 4, 5, 6, 7, 8, time.UTC),
		},
	}))

	// An email must be sent.
	msg = ctx.emailWithoutURLs()
	fmt.Printf("%s", msg)
	c.expectEQ(msg.Body, `The commit that was suspected to fix the issue was backported to the fuzzed
kernel trees.

commit newhash
Author: Someone <someone@somewhere.com>
Date:   Sat Mar 4 05:06:07 2000 +0000

    kernel: fix a bug

If you believe this is correct, please reply with
#syz fix: kernel: fix a bug

The commit was initially detected here:

commit deadf00d
git tree: upstream
Author: Someone <someone@somewhere.com>
Date:   Wed Feb 9 04:05:06 2000 +0000

    kernel: fix a bug

bisection log:  %URL%
final oops:     %URL%
console output: %URL%
kernel config:  %URL%
dashboard link: %URL%
syz repro:      %URL%
C reproducer:   %URL%
`)
	// Only one email.
	ctx.ctx.expectNoEmail()

	// The commit should disappear from the missing backports list.
	reply, err = ctx.ctx.AuthGET(AccessAdmin, "/tree-tests/backports")
	c.expectOK(err)
	assert.NotContains(t, string(reply), treeTestCrashTitle)
	assert.NotContains(t, string(reply), "deadf00d")
}

func TestNonfinalFixCandidateBisect(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `downstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results:    []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias: `upstream`,
			// Ignore these jobs.
			results: []treeTestEntryPeriod{},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.reportToEmail()
	ctx.ctx.advanceTime(time.Hour)

	// Ensure the code does not fail.
	job := ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{BisectFix: true})
	assert.Equal(t, "", job.ID)
}

func TestTreeBisectionBeforeOrigin(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.reportToEmail()
	// Ensure the job is no longer created.
	ctx.ctx.advanceTime(time.Hour)
	job := ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{BisectFix: true})
	assert.Equal(t, "", job.ID)
}

func TestTreeOriginErrors(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	// Make sure testing works fine despite patch testing errors.
	ctx := setUpTreeTest(c, downstreamUpstreamRepos)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias: `downstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestError},
				{fromDay: 16, result: treeTestCrash},
			},
		},
		{
			alias: `upstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestError},
				{fromDay: 31, result: treeTestCrash},
			},
		},
	}
	ctx.jobTestDays = []int{1, 16, 31}
	ctx.moveToDay(1)
	ctx.ensureLabels() // Not enough information yet.
	// Lts got unbroken.
	ctx.moveToDay(16)
	ctx.ensureLabels(`origin:lts`) // We don't know any better so far.
	// Upstream got unbroken.
	ctx.moveToDay(31)
	ctx.ensureLabels(`origin:upstream`)
	c.expectEQ(ctx.entries[0].jobsDone, 0)
	c.expectEQ(ctx.entries[1].jobsDone, 2)
	c.expectEQ(ctx.entries[2].jobsDone, 3)
}

var downstreamUpstreamRepos = []KernelRepo{
	{
		URL:             `https://downstream.repo/repo`,
		Branch:          `master`,
		Alias:           `downstream`,
		LabelIntroduced: `downstream`,
		CommitInflow: []KernelRepoLink{
			{
				Alias: `upstream`,
			},
			{
				Alias: `lts`,
				Merge: true,
			},
		},
	},
	{
		URL:             `https://lts.repo/repo`,
		Branch:          `lts-master`,
		Alias:           `lts`,
		LabelIntroduced: `lts`,
		CommitInflow: []KernelRepoLink{
			{
				Alias:       `upstream`,
				Merge:       false,
				BisectFixes: true,
			},
		},
	},
	{
		URL:             `https://upstream.repo/repo`,
		Branch:          `upstream-master`,
		Alias:           `upstream`,
		LabelIntroduced: `upstream`,
	},
}

func TestOriginTreeNoMergeLts(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, ltsUpstreamRepos)
	ctx.uploadBug(`https://lts.repo/repo`, `lts-master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `lts`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels(`origin:lts-only`)
	c.expectEQ(ctx.entries[0].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
}

func TestOriginTreeNoMergeNoLabel(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, ltsUpstreamRepos)
	ctx.uploadBug(`https://lts.repo/repo`, `lts-master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `lts`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels()
	// It should habe been enough to run jobs just once.
	c.expectEQ(ctx.entries[0].jobsDone, 0)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
}

func TestTreeOriginRepoChanged(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, ltsUpstreamRepos)

	// First do tests from one repository.
	ctx.uploadBug(`https://lts.repo/repo`, `lts-master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `lts`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10, 20, 25, 30, 62}
	ctx.moveToDay(10)
	ctx.ensureLabels(`origin:lts-only`)
	c.expectEQ(ctx.entries[0].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)

	// Now update the repository.
	ctx.updateRepos([]KernelRepo{
		{
			URL:               `https://new-lts.repo/repo`,
			Branch:            `lts-master`,
			Alias:             `lts`,
			LabelIntroduced:   `lts-only`,
			ReportingPriority: 9,
			CommitInflow: []KernelRepoLink{
				{
					Alias: `upstream`,
					Merge: false,
				},
			},
		},
		{
			URL:    `https://upstream.repo/repo`,
			Branch: `upstream-master`,
			Alias:  `upstream`,
		},
	})
	ctx.entries = []treeTestEntry{
		{
			alias: `lts`,
			results: []treeTestEntryPeriod{
				{fromDay: 30, result: treeTestError},
				{fromDay: 60, result: treeTestCrash},
			},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.moveToDay(20)
	ctx.ensureLabels(`origin:lts-only`) // No new builds -- nothing we can do.

	// Upload a new manager build.
	build := ctx.uploadBuild(`https://new-lts.repo/repo`, `lts-master`)
	ctx.moveToDay(25)
	ctx.ensureLabels(`origin:lts-only`) // Still nothing we can do, no crashes so far.

	// Now upload a new crash.
	ctx.uploadBuildCrash(build, dashapi.ReproLevelC)
	ctx.moveToDay(30)
	ctx.ensureLabels() // We are no longer sure about tags.

	// After the new tree starts to build again, we can calculate the results again.
	ctx.moveToDay(62)
	ctx.ensureLabels(`origin:lts-only`) // We are no longer sure about tags.
	c.expectEQ(ctx.entries[0].jobsDone, 2)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
}

var ltsUpstreamRepos = []KernelRepo{
	{
		URL:             `https://lts.repo/repo`,
		Branch:          `lts-master`,
		Alias:           `lts`,
		LabelIntroduced: `lts-only`,
		CommitInflow: []KernelRepoLink{
			{
				Alias: `upstream`,
				Merge: false,
			},
		},
	},
	{
		URL:    `https://upstream.repo/repo`,
		Branch: `upstream-master`,
		Alias:  `upstream`,
	},
}

func TestOriginNoNextTree(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, upstreamNextRepos)
	ctx.uploadBug(`https://upstream.repo/repo`, `upstream-master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels()
}

func TestOriginNoNextFixed(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, upstreamNextRepos)
	ctx.uploadBug(`https://next.repo/repo`, `next-master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `next`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels()
	c.expectEQ(ctx.entries[0].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
}

func TestOriginNoNext(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, upstreamNextRepos)
	ctx.uploadBug(`https://next.repo/repo`, `next-master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `next`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels()
	c.expectEQ(ctx.entries[0].jobsDone, 0)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
}

func TestOriginNext(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, upstreamNextRepos)
	ctx.uploadBug(`https://next.repo/repo`, `next-master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `next`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:   `upstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestOK}},
		},
	}
	ctx.jobTestDays = []int{10}
	ctx.moveToDay(10)
	ctx.ensureLabels(`origin:next`)
	c.expectEQ(ctx.entries[0].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
}

var upstreamNextRepos = []KernelRepo{
	{
		URL:    `https://upstream.repo/repo`,
		Branch: `upstream-master`,
		Alias:  `upstream`,
		CommitInflow: []KernelRepoLink{
			{
				Alias: `next`,
				Merge: false,
			},
		},
	},
	{
		URL:          `https://next.repo/repo`,
		Branch:       `next-master`,
		Alias:        `next`,
		LabelReached: `next`,
	},
}

func TestMissingLtsBackport(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamBackports)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias: `downstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
		{
			alias: `lts`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
				{fromDay: 46, result: treeTestOK},
			},
		},
		{
			alias: `upstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
	}
	ctx.jobTestDays = []int{0, 46}
	ctx.moveToDay(46)
	ctx.ensureLabels(`missing-backport`)
	c.expectEQ(ctx.entries[0].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
}

func TestMissingUpstreamBackport(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamBackports)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias: `downstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
		{
			alias: `lts`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
		{
			alias: `upstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
				{fromDay: 31, result: treeTestOK},
			},
		},
	}
	ctx.jobTestDays = []int{0, 46}
	ctx.moveToDay(46)
	ctx.ensureLabels(`missing-backport`)
	c.expectEQ(ctx.entries[0].jobsDone, 1)
	c.expectEQ(ctx.entries[1].jobsDone, 2)
	c.expectEQ(ctx.entries[1].jobsDone, 2)
}

func TestNotMissingBackport(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, downstreamUpstreamBackports)
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias: `downstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestOK},
			},
		},
		{
			alias: `lts`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestOK},
			},
		},
		{
			alias: `upstream`,
			results: []treeTestEntryPeriod{
				{fromDay: 0, result: treeTestCrash},
			},
		},
	}
	ctx.jobTestDays = []int{0, 46}
	ctx.moveToDay(46)
	ctx.ensureLabels()
	c.expectEQ(ctx.entries[0].jobsDone, 0)
	c.expectEQ(ctx.entries[1].jobsDone, 1)
	c.expectEQ(ctx.entries[2].jobsDone, 1)
	c.expectEQ(ctx.entries[3].jobsDone, 2)
}

var downstreamUpstreamBackports = []KernelRepo{
	{
		URL:    `https://downstream.repo/repo`,
		Branch: `master`,
		Alias:  `downstream`,
		CommitInflow: []KernelRepoLink{
			{
				Alias: `lts`,
				Merge: true,
			},
			{
				Alias: `upstream`,
			},
		},
		DetectMissingBackports: true,
	},
	{
		URL:    `https://lts.repo/repo`,
		Branch: `lts-master`,
		Alias:  `lts`,
		CommitInflow: []KernelRepoLink{
			{
				Alias: `upstream`,
				Merge: false,
			},
		},
	},
	{
		URL:    `https://upstream.repo/repo`,
		Branch: `upstream-master`,
		Alias:  `upstream`,
	},
}

func TestTreeConfigAppend(t *testing.T) {
	c := NewCtx(t)
	defer c.Close()

	ctx := setUpTreeTest(c, []KernelRepo{
		{
			URL:    `https://downstream.repo/repo`,
			Branch: `master`,
			Alias:  `downstream`,
			CommitInflow: []KernelRepoLink{
				{
					Alias: `lts`,
					Merge: true,
				},
			},
			LabelIntroduced: `downstream`,
		},
		{
			URL:             `https://lts.repo/repo`,
			Branch:          `lts-master`,
			Alias:           `lts`,
			LabelIntroduced: `lts`,
			AppendConfig:    "\nCONFIG_TEST=y",
		},
	})
	ctx.uploadBug(`https://downstream.repo/repo`, `master`, dashapi.ReproLevelC)
	ctx.entries = []treeTestEntry{
		{
			alias:   `downstream`,
			results: []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
		{
			alias:      `lts`,
			mergeAlias: `downstream`,
			results:    []treeTestEntryPeriod{{fromDay: 0, result: treeTestCrash}},
		},
	}
	ctx.jobTestDays = []int{10}
	tested := false
	ctx.validateJob = func(resp *dashapi.JobPollResp) {
		if resp.KernelBranch == "lts-master" {
			tested = true
			assert.Contains(t, string(resp.KernelConfig), "\nCONFIG_TEST=y")
		}
	}
	ctx.moveToDay(10)
	assert.True(t, tested)
}

func setUpTreeTest(ctx *Ctx, repos []KernelRepo) *treeTestCtx {
	ret := &treeTestCtx{
		ctx:     ctx,
		client:  ctx.makeClient(clientTreeTests, keyTreeTests, true),
		manager: "test-manager",
	}
	ret.updateRepos(repos)
	return ret
}

type treeTestCtx struct {
	ctx         *Ctx
	client      *apiClient
	bug         *Bug
	bugReport   *dashapi.BugReport
	start       time.Time
	entries     []treeTestEntry
	perAlias    map[string]KernelRepo
	jobTestDays []int
	manager     string
	validateJob func(*dashapi.JobPollResp)
}

func (ctx *treeTestCtx) now() time.Time {
	// Yep, that's a bit too much repetition.
	return timeNow(ctx.ctx.ctx)
}

func (ctx *treeTestCtx) updateRepos(repos []KernelRepo) {
	checkKernelRepos("tree-tests", ctx.ctx.config().Namespaces["tree-tests"], repos)
	ctx.perAlias = map[string]KernelRepo{}
	for _, repo := range repos {
		ctx.perAlias[repo.Alias] = repo
	}
	ctx.ctx.setKernelRepos("tree-tests", repos)
}

func (ctx *treeTestCtx) uploadBuild(repo, branch string) *dashapi.Build {
	build := testBuild(1)
	build.ID = fmt.Sprintf("%d", ctx.now().Unix())
	build.Manager = ctx.manager
	build.KernelRepo = repo
	build.KernelBranch = branch
	build.KernelCommit = build.ID
	ctx.client.UploadBuild(build)
	return build
}

const treeTestCrashTitle = "cross-tree bug title"

func (ctx *treeTestCtx) uploadBuildCrash(build *dashapi.Build, lvl dashapi.ReproLevel) {
	crash := testCrash(build, 1)
	crash.Title = treeTestCrashTitle
	if lvl > dashapi.ReproLevelNone {
		crash.ReproSyz = []byte("getpid()")
	}
	if lvl == dashapi.ReproLevelC {
		crash.ReproC = []byte("getpid()")
	}
	ctx.client.ReportCrash(crash)
	if ctx.bug == nil || ctx.bug.ReproLevel < lvl {
		ctx.bugReport = ctx.client.pollBug()
		if ctx.bug == nil {
			bug, _, err := findBugByReportingID(ctx.ctx.ctx, ctx.bugReport.ID)
			ctx.ctx.expectOK(err)
			ctx.bug = bug
		}
	}
}

func (ctx *treeTestCtx) uploadBug(repo, branch string, lvl dashapi.ReproLevel) {
	build := ctx.uploadBuild(repo, branch)
	ctx.uploadBuildCrash(build, lvl)
}

func (ctx *treeTestCtx) moveToDay(tillDay int) {
	ctx.ctx.t.Helper()
	if ctx.start.IsZero() {
		ctx.start = ctx.now()
	}
	for _, seqDay := range ctx.jobTestDays {
		if seqDay > tillDay {
			break
		}
		now := ctx.now()
		day := ctx.start.Add(time.Hour * 24 * time.Duration(seqDay))
		if day.Before(now) || ctx.start != ctx.now() && day.Equal(now) {
			continue
		}
		ctx.ctx.advanceTime(day.Sub(now))
		ctx.ctx.t.Logf("executing jobs on day %d", seqDay)
		// Execute jobs until they exist.
		for {
			pollResp := ctx.client.pollSpecificJobs(ctx.manager, dashapi.ManagerJobs{
				TestPatches: true,
			})
			if pollResp.ID == "" {
				break
			}
			if ctx.validateJob != nil {
				ctx.validateJob(pollResp)
			}
			ctx.ctx.advanceTime(time.Minute)
			ctx.doJob(pollResp, seqDay)
		}
	}
}

func (ctx *treeTestCtx) doJob(resp *dashapi.JobPollResp, day int) {
	respValues := []string{
		resp.KernelRepo,
		resp.KernelBranch,
		resp.MergeBaseRepo,
		resp.MergeBaseBranch,
	}
	sort.Strings(respValues)
	var found *treeTestEntry
	for i, entry := range ctx.entries {
		entryValues := []string{
			ctx.perAlias[entry.alias].URL,
			ctx.perAlias[entry.alias].Branch,
		}
		if entry.mergeAlias != "" {
			entryValues = append(entryValues,
				ctx.perAlias[entry.mergeAlias].URL,
				ctx.perAlias[entry.mergeAlias].Branch)
		} else {
			entryValues = append(entryValues, "", "")
		}
		sort.Strings(entryValues)
		if reflect.DeepEqual(respValues, entryValues) {
			found = &ctx.entries[i]
			break
		}
	}
	if found == nil {
		ctx.ctx.t.Fatalf("unknown job request: %#v", resp)
		return // to avoid staticcheck false positive about nil deref
	}
	// Figure out what should the result be.
	result := treeTestOK
	build := testBuild(1)
	var anyFound bool
	for _, item := range found.results {
		if day >= item.fromDay {
			result = item.result
			build.KernelCommit = item.commit
			anyFound = true
		}
	}
	if !anyFound {
		// Just ignore the job.
		return
	}
	if build.KernelCommit == "" {
		build.KernelCommit = strings.Repeat("f", 40)[:40]
	}
	build.KernelRepo = resp.KernelRepo
	build.KernelBranch = resp.KernelBranch
	build.ID = fmt.Sprintf("%s_%s_%s_%d", resp.KernelRepo, resp.KernelBranch, resp.KernelCommit, day)
	jobDoneReq := &dashapi.JobDoneReq{
		ID:    resp.ID,
		Build: *build,
	}
	switch result {
	case treeTestOK:
	case treeTestCrash:
		jobDoneReq.CrashTitle = "crash title"
		jobDoneReq.CrashLog = []byte("test crash log")
		jobDoneReq.CrashReport = []byte("test crash report")
	case treeTestError:
		jobDoneReq.Error = []byte("failed to apply patch")
	}
	found.jobsDone++
	ctx.ctx.expectOK(ctx.client.JobDone(jobDoneReq))
}

func (ctx *treeTestCtx) ensureLabels(labels ...string) {
	ctx.ctx.t.Helper()
	bug := ctx.loadBug()
	var bugLabels []string
	for _, item := range bug.Labels {
		bugLabels = append(bugLabels, item.String())
	}
	assert.ElementsMatch(ctx.ctx.t, labels, bugLabels)
}

func (ctx *treeTestCtx) loadBug() *Bug {
	ctx.ctx.t.Helper()
	if ctx.bug == nil {
		ctx.ctx.t.Fatalf("no bug has been created so far")
	}
	bug := new(Bug)
	ctx.ctx.expectOK(db.Get(ctx.ctx.ctx, ctx.bug.key(ctx.ctx.ctx), bug))
	ctx.bug = bug
	return bug
}

func (ctx *treeTestCtx) bugLink() string {
	return fmt.Sprintf("/bug?id=%v", ctx.bug.key(ctx.ctx.ctx).StringID())
}

func (ctx *treeTestCtx) reportToEmail() *aemail.Message {
	ctx.client.updateBug(ctx.bugReport.ID, dashapi.BugStatusUpstream, "")
	return ctx.ctx.pollEmailBug()
}

func (ctx *treeTestCtx) fullBugInfo() *dashapi.FullBugInfo {
	info, err := ctx.client.LoadFullBug(&dashapi.LoadFullBugReq{
		BugID: ctx.bugReport.ID,
	})
	ctx.ctx.expectOK(err)
	return info
}

var urlRe = regexp.MustCompile(`(https?://[\w\./\?\=&]+)`)

func (ctx *treeTestCtx) emailWithoutURLs() *aemail.Message {
	msg := ctx.ctx.pollEmailBug()
	msg.Body = urlRe.ReplaceAllString(msg.Body, "%URL%")
	return msg
}

type treeTestEntry struct {
	alias      string
	mergeAlias string
	results    []treeTestEntryPeriod
	jobsDone   int
}

type treeTestResult string

const (
	treeTestCrash treeTestResult = "crash"
	treeTestOK    treeTestResult = "ok"
	treeTestError treeTestResult = "error"
)

type treeTestEntryPeriod struct {
	fromDay int
	result  treeTestResult
	commit  string
}

func TestRepoGraph(t *testing.T) {
	g, err := makeRepoGraph(downstreamUpstreamRepos)
	if err != nil {
		t.Fatal(err)
	}

	downstream := g.nodeByAlias(`downstream`)
	lts := g.nodeByAlias(`lts`)
	upstream := g.nodeByAlias(`upstream`)

	// Test the downstream node.
	if diff := cmp.Diff(map[*repoNode]bool{
		lts:      true,
		upstream: false,
	}, downstream.reachable(true)); diff != "" {
		t.Fatal(diff)
	}
	if diff := cmp.Diff(map[*repoNode]bool{}, downstream.reachable(false)); diff != "" {
		t.Fatal(diff)
	}

	// Test the lts node.
	if diff := cmp.Diff(map[*repoNode]bool{
		upstream: false,
	}, lts.reachable(true)); diff != "" {
		t.Fatal(diff)
	}
	if diff := cmp.Diff(map[*repoNode]bool{
		downstream: true,
	}, lts.reachable(false)); diff != "" {
		t.Fatal(diff)
	}

	// Test the upstream node.
	if diff := cmp.Diff(map[*repoNode]bool{}, upstream.reachable(true)); diff != "" {
		t.Fatal(diff)
	}
	if diff := cmp.Diff(map[*repoNode]bool{
		downstream: false,
		lts:        false,
	}, upstream.reachable(false)); diff != "" {
		t.Fatal(diff)
	}
}

func TestRepoGraphMergeFirst(t *testing.T) {
	// Test whether we prioritize merge links.
	g, err := makeRepoGraph([]KernelRepo{
		{
			URL:    `https://downstream.repo/repo`,
			Branch: `master`,
			Alias:  `downstream`,
			CommitInflow: []KernelRepoLink{
				{
					Alias: `upstream`,
					Merge: false,
				},
				{
					Alias: `lts`,
					Merge: true,
				},
			},
		},
		{
			URL:    `https://lts.repo/repo`,
			Branch: `lts-master`,
			Alias:  `lts`,
			CommitInflow: []KernelRepoLink{
				{
					Alias: `upstream`,
					Merge: true,
				},
			},
		},
		{
			URL:    `https://upstream.repo/repo`,
			Branch: `upstream-master`,
			Alias:  `upstream`,
		},
	})
	if err != nil {
		t.Fatal(err)
	}

	downstream := g.nodeByAlias(`downstream`)
	lts := g.nodeByAlias(`lts`)
	upstream := g.nodeByAlias(`upstream`)

	// Test the downstream node.
	if diff := cmp.Diff(map[*repoNode]bool{
		lts:      true,
		upstream: true,
	}, downstream.reachable(true)); diff != "" {
		t.Fatal(diff)
	}
	if diff := cmp.Diff(map[*repoNode]bool{}, downstream.reachable(false)); diff != "" {
		t.Fatal(diff)
	}
}
